﻿#Region "Copyright/License"

'  Copyright (C) 2011, 2012, 2013 Paycircuit.com, Thinking Spot, Ed Chavez
'  http://goo.gl/jty8r
' 
'  Licensed under the Apache License, Version 2.0 (the "License");
'  you may not use this file except in compliance with the License.
'  You may obtain a copy of the License at
' 
'       http://www.apache.org/licenses/LICENSE-2.0
' 
'  Unless required by applicable law or agreed to in writing, software
'  distributed under the License is distributed on an "AS IS" BASIS,
'  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
'  See the License for the specific language governing permissions and
'  limitations under the License.

#End Region

Imports System.Text
Imports System.Security.Cryptography

Namespace google.iap

    Public Module JWTHelpers

        ''' <summary>
        ''' DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc).Ticks = 621355968000000000
        ''' </summary>
        ''' <remarks></remarks>
        Const UNIX_EPOCH_TICKS As Long = 621355968000000000L

#Region "JWT"

        ''' <summary>
        ''' Base64url encoder
        ''' </summary>
        ''' <param name="jsonString">String to Base64url encode</param>
        ''' <returns>Base64url string</returns>
        ''' <remarks></remarks>
        Friend Function jwtEncodeB64Url(ByVal jsonString As String) As String
            If Not String.IsNullOrEmpty(jsonString) Then
                Return convertToBase64url(System.Convert.ToBase64String(New UTF8Encoding(True, True).GetBytes(jsonString)))
            Else
                Throw New ArgumentNullException("jsonString", "String is required.")
            End If
        End Function

        ''' <summary>
        ''' Base64url decoder
        ''' </summary>
        ''' <param name="base64UrlString">Base64url string to decode</param>
        ''' <returns>Decoded string</returns>
        ''' <remarks></remarks>
        Friend Function jwtDecodeB64Url(ByVal base64UrlString As String) As String
            If Not String.IsNullOrEmpty(base64UrlString) Then
                Try
                    base64UrlString = base64UrlString.Replace("-", "+").Replace("_", "/")
                    Dim m = base64UrlString.Length Mod 4
                    If m <> 0 Then
                        Select Case m
                            Case 2, 3
                                Return New UTF8Encoding(True, True).GetString(System.Convert.FromBase64String(base64UrlString.PadRight(base64UrlString.Length + (4 - m), "="c)))
                            Case Else
                                Return String.Empty
                        End Select
                    Else
                        Return New UTF8Encoding(True, True).GetString(System.Convert.FromBase64String(base64UrlString))
                    End If
                Catch ex As Exception
                    Throw New ArgumentException("String is malformed/invalid base64 string.", "base64UrlString", ex)
                End Try
            Else
                Throw New ArgumentNullException("base64UrlStrings", "String is required.")
            End If
        End Function

        ''' <summary>
        ''' Generate HMAC SHA-256 signature
        ''' </summary>
        ''' <param name="jwtHeaderAndClaim">Concatenated Base64url Header Input and Payload Input, delimited by period (.)</param>
        ''' <param name="sellerSecret">Your Seller Secret</param>
        ''' <returns>JWT signature</returns>
        ''' <remarks></remarks>
        Private Function jwtHMAC256(ByVal jwtHeaderAndClaim As String, ByVal sellerSecret As String) As String
            If Not String.IsNullOrEmpty(jwtHeaderAndClaim) AndAlso Not String.IsNullOrEmpty(sellerSecret) Then
                Dim _utf8 As New UTF8Encoding(True, True)
                Dim _key As Byte() = _utf8.GetBytes(sellerSecret)
                Dim _btxt As Byte() = _utf8.GetBytes(jwtHeaderAndClaim)
                Dim _str As String = String.Empty
                Using hmac As New HMACSHA256(_key)
                    _str = convertToBase64url(System.Convert.ToBase64String(hmac.ComputeHash(_btxt)))
                    hmac.Clear()
                End Using
                Return _str
            Else
                Throw New ArgumentNullException(If(String.IsNullOrEmpty(jwtHeaderAndClaim), "jwtHeaderAndClaim", "hkey"), "Argument cannot be null/empty")
            End If
        End Function

        ''' <summary>
        ''' Build JWT
        ''' </summary>
        ''' <param name="HeaderObj">JWTHeaderObject</param>
        ''' <param name="ClaimObj">InAppItemObject</param>
        ''' <param name="sellerSecret">Your Seller Secret</param>
        ''' <returns>JWT string</returns>
        ''' <remarks></remarks>
        Function buildJWT(ByVal HeaderObj As JWTHeaderObject, ByVal ClaimObj As InAppItemObject, ByVal sellerSecret As String) As String
            If Not IsNothing(HeaderObj) AndAlso Not IsNothing(ClaimObj) AndAlso Not String.IsNullOrEmpty(sellerSecret) Then
                Try
                    Dim _jh As String = jwtEncodeB64Url(JSONHelpers.dataContractToJSON(HeaderObj))
                    Dim _jp As String = jwtEncodeB64Url(JSONHelpers.dataContractToJSON(ClaimObj))
                    Dim _sig As String = String.Empty
                    Select Case parseJWTHashEnum(HeaderObj.alg)
                        Case JWTHeaderObject.JWTHash.HS256
                            _sig = jwtHMAC256(String.Concat(_jh, ".", _jp), sellerSecret)
                        Case JWTHeaderObject.JWTHash.HS384
                            _sig = jwtHMAC384(String.Concat(_jh, ".", _jp), sellerSecret)
                        Case JWTHeaderObject.JWTHash.HS512
                            _sig = jwtHMAC512(String.Concat(_jh, ".", _jp), sellerSecret)
                        Case Else
                            _sig = String.Empty
                    End Select
                    Return String.Concat(_jh, ".", _jp, ".", _sig)
                Catch ex As Exception
                    Throw New Exception(ex.Message.ToString)
                End Try
            Else
                Throw New ArgumentNullException(If(IsNothing(HeaderObj), New JWTHeaderObject().ToString, If(IsNothing(ClaimObj), New InAppItemObject().ToString, "sellerSecret")), "Null Argument")
            End If
        End Function


        ''' <summary>
        ''' Verify JWT (header test and signature matching only)
        ''' </summary>
        ''' <param name="jwtString">The raw JWT string to parse</param>
        ''' <param name="sellerSecret">Your Seller Secret</param>
        ''' <param name="jwtHeader">out: string for deserializing to JWTHeaderObject if TRUE</param>
        ''' <param name="jwtClaim">out: string for deserializing to InAppItemObject if TRUE</param>
        ''' <returns>Boolean</returns>
        ''' <remarks></remarks>
        Function verifyJWT(ByVal jwtString As String, ByVal sellerSecret As String,
                           ByRef jwtHeader As String, ByRef jwtClaim As String) As Boolean
            Dim _arr() As String = jwtString.Split("."c)
            If _arr.Length = 3 Then
                jwtHeader = _arr(0)
                jwtClaim = _arr(1)
                Dim _sig As String = _arr(2)
                If Not String.IsNullOrEmpty(jwtHeader) AndAlso Not String.IsNullOrEmpty(jwtClaim) AndAlso Not String.IsNullOrEmpty(_sig) Then
                    Try
                        Dim header As JWTHeaderObject = TryCast(dataContractJSONToObj(jwtHeader, New JWTHeaderObject()), JWTHeaderObject)
                        'JWT header test and signature verification
                        '*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~
                        Return header IsNot Nothing AndAlso verifySignature(header.alg, jwtHeader, jwtClaim, _sig, sellerSecret)
                    Catch ex As Exception
                        Return False
                    End Try
                Else
                    Return False
                End If
            Else
                Return False
            End If
        End Function

        ''' <summary>
        '''  A stricter JWT verification routine. Verifies values in header and payload (not just signuature matching). Use to validate Google success and/or failure POSTBACK.
        ''' </summary>       
        ''' <param name="iss">Issuer claim: will be compared with JWT iss. Currently "Google" (case-sensitive).</param>
        ''' <param name="aud">Audience claim: will be compared with JWT aud. Must match your Merchant/Seller ID (case-sensitive).</param>
        ''' <param name="typ">Type Claim: will by compared with JWT typ. Currently "goog.payments.inapp.buyItem.v1.postback"</param>
        ''' <param name="clockSkewMinutes">Minute(s) to adjust your clock. 0 for no adjustment. Behavior: add when verifying iat, subtract when verifying exp. Restrict to a few mintues only.</param>
        ''' <param name="jwtString">The raw JWT string to parse</param>
        ''' <param name="sellerSecret">Your Seller Secret</param>
        ''' <param name="HeaderObj">out: JWTHeaderObject</param>
        ''' <param name="ClaimObj">out: InAppItemObject</param>
        ''' <returns>Boolean</returns>
        ''' <remarks>
        ''' To use this routine to verify your own JWT (instead of Google's Postback), you must provide a response object. Otherwise use other verifyJWT() overload
        ''' 
        ''' 10/2013: * removed AndAlso ClaimObj.request IsNot Nothing - a failure postback will not have request object
        '''          * typ for failure postback is "google/payments/inapp/subscription/v1/canceled"
        ''' </remarks>
        Function verifyJWT(ByVal iss As String, ByVal aud As String, ByVal typ As String, ByVal clockSkewMinutes As Integer, ByVal jwtString As String, ByVal sellerSecret As String,
                           ByRef HeaderObj As JWTHeaderObject, ByRef ClaimObj As InAppItemObject) As Boolean
            Dim _arr() As String = jwtString.Split("."c)
            If _arr.Length = 3 Then
                Dim _jwth As String = _arr(0)
                Dim _jwtp As String = _arr(1)
                Dim _sig As String = _arr(2)
                If Not String.IsNullOrEmpty(_jwth) AndAlso Not String.IsNullOrEmpty(_jwtp) AndAlso Not String.IsNullOrEmpty(_sig) Then
                    Try
                        HeaderObj = TryCast(dataContractJSONToObj(_jwth, New JWTHeaderObject()), JWTHeaderObject)
                        'JWT header test and signature verification
                        '*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~
                        If HeaderObj IsNot Nothing AndAlso verifySignature(HeaderObj.alg, _jwth, _jwtp, _sig, sellerSecret) Then
                            'If signature is verified, proceed with JWT payload verification
                            '*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~
                            ClaimObj = TryCast(dataContractJSONToObj(_jwtp, New InAppItemObject()), InAppItemObject)
                            'ClaimObj must have request and response - (!) see remarks for removal of request object check
                            '*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~
                            If ClaimObj IsNot Nothing AndAlso ClaimObj.response IsNot Nothing Then
                                Dim _validexp As Boolean = True
                                Dim _myservertime As Integer = myServerClock()
                                If ClaimObj.exp.HasValue Then
                                    'exp must be > than current system clock time
                                    _validexp = _myservertime - (clockSkewMinutes * 60) < ClaimObj.exp.Value
                                End If
                                'iat must be < than current system clock time, exp must be valid, iss, aud and typ claims are verified
                                Dim _validTyp As Boolean
                                Select Case True
                                    Case ClaimObj.isFailurePostback
                                        _validTyp = String.Equals(ClaimObj.typ, InAppItemObject.SUBSCRIPTION_FAILURE_POSTBACK_TYP, StringComparison.Ordinal)
                                    Case ClaimObj.isSuccessPostback
                                        _validTyp = If(ClaimObj.isSingleItem, String.Equals(ClaimObj.typ, InAppItemObject.SUCCESS_POSTBACK_ITEM_TYP, StringComparison.Ordinal), String.Equals(ClaimObj.typ, InAppItemObject.SUCCESS_POSTBACK_SUBSCRIPTION_TYP, StringComparison.Ordinal))
                                    Case Else
                                        _validTyp = String.Equals(ClaimObj.typ, typ, StringComparison.Ordinal)
                                End Select
                                Return _validexp AndAlso
                                    _validTyp AndAlso
                                    ClaimObj.iat < _myservertime + (clockSkewMinutes * 60) AndAlso
                                    String.Equals(ClaimObj.iss, iss, StringComparison.Ordinal) AndAlso
                                    String.Equals(ClaimObj.aud, aud, StringComparison.Ordinal)
                            Else
                                Return False
                            End If
                        Else
                            Return False
                        End If
                    Catch ex As Exception
                        Return False
                    End Try
                Else
                    Return False
                End If
            Else
                Return False
            End If
        End Function

#End Region

#Region "Utils"

        ''' <summary>
        ''' Parses string to enum
        ''' </summary>
        ''' <param name="algString">String to parse</param>
        ''' <returns>JWTHeaderObject.JWTHash enum (returns JWTHeaderObject.JWTHash.none if match is not found). Google In-App Payments currently only uses HS256 (06/2011)</returns>
        ''' <remarks></remarks>
        Friend Function parseJWTHashEnum(ByVal algString As String) As JWTHeaderObject.JWTHash
            If Not String.IsNullOrEmpty(algString) Then
                Dim _ci As JWTHeaderObject.JWTHash
                Dim boolFunc As Boolean = [Enum].TryParse(Of JWTHeaderObject.JWTHash)(algString, False, _ci)
                If boolFunc Then
                    Return _ci
                Else
                    Return JWTHeaderObject.JWTHash.none
                End If
            Else
                Return JWTHeaderObject.JWTHash.none
            End If
        End Function

        ''' <summary>
        ''' The number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the desired date/time. 
        ''' </summary>
        ''' <returns>Integer</returns>
        ''' <remarks>If by any chance this is still in use in 2038, revisit this before 1/19/2038 (Int32 to Int64)</remarks>
        Function myServerClock() As Integer
            Return CInt((DateTime.UtcNow.Ticks - UNIX_EPOCH_TICKS) / 10000000)
        End Function

        ''' <summary>
        ''' Converts a given date to number of seconds from Unix Epoch Time.
        ''' </summary>
        ''' <param name="t">Local DateTime. Use local datetime value (no conversion) - this function will convert datetime argument to UTC.</param>
        ''' <returns>Integer value representing time from Unix epoch</returns>
        ''' <remarks></remarks>
        Function dateToEpochTime(ByVal t As DateTime) As Integer
            Return CInt((t.ToUniversalTime.Ticks - UNIX_EPOCH_TICKS) / 10000000)
        End Function

        ''' <summary>
        ''' Converts base64 string to base64url
        ''' </summary>
        ''' <param name="s">base64 string</param>
        ''' <returns>base64url string</returns>
        ''' <remarks></remarks>
        Private Function convertToBase64url(ByVal s As String) As String
            Return s.Replace("=", String.Empty).Replace("+", "-").Replace("/", "_")
        End Function

        ''' <summary>
        ''' Signature verification
        ''' </summary>
        ''' <param name="alg">JWT Header alg string</param>
        ''' <param name="jwtHeader">Raw JWT header claim</param>
        ''' <param name="jwtClaim">Raw JWT payload claim</param>
        ''' <param name="jwtSignature">Raw JWT signature</param>
        ''' <param name="sellerSecret">YOUR SELLER KEY</param>
        ''' <returns>Boolean</returns>
        ''' <remarks></remarks>
        Private Function verifySignature(ByVal alg As String, ByVal jwtHeader As String, ByVal jwtClaim As String, ByVal jwtSignature As String, ByVal sellerSecret As String) As Boolean
            Select Case parseJWTHashEnum(alg)
                Case JWTHeaderObject.JWTHash.HS256
                    Return String.Equals(jwtSignature, jwtHMAC256(String.Concat(jwtHeader, ".", jwtClaim), sellerSecret), StringComparison.Ordinal)
                Case JWTHeaderObject.JWTHash.HS384
                    Return String.Equals(jwtSignature, jwtHMAC384(String.Concat(jwtHeader, ".", jwtClaim), sellerSecret), StringComparison.Ordinal)
                Case JWTHeaderObject.JWTHash.HS512
                    Return String.Equals(jwtSignature, jwtHMAC512(String.Concat(jwtHeader, ".", jwtClaim), sellerSecret), StringComparison.Ordinal)
                Case Else
                    Return False
            End Select
        End Function

#End Region

#Region "Not used for Google In-App Payments"

        ''' <summary>
        ''' Generate HMAC SHA-384 signature. Not used for Google In-App Payments (06/2011).
        ''' </summary>
        ''' <param name="jwtHeaderAndClaim">Concatenated Base64url Header Input and Payload Input, delimited by period (.)</param>
        ''' <param name="sellerSecret">Your Seller Secret</param>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Private Function jwtHMAC384(ByVal jwtHeaderAndClaim As String, ByVal sellerSecret As String) As String
            If Not String.IsNullOrEmpty(jwtHeaderAndClaim) AndAlso Not String.IsNullOrEmpty(sellerSecret) Then
                Dim _utf8 As New UTF8Encoding(True, True)
                Dim _key As Byte() = _utf8.GetBytes(sellerSecret)
                Dim _btxt As Byte() = _utf8.GetBytes(jwtHeaderAndClaim)
                Dim _str As String = String.Empty
                Using hmac As New HMACSHA384(_key)
                    _str = System.Convert.ToBase64String(hmac.ComputeHash(_btxt)).Replace("=", String.Empty).Replace("+", "-").Replace("/", "_")
                    hmac.Clear()
                End Using
                Debug.WriteLine("From hash: " & _str)
                Return _str
            Else
                Throw New ArgumentNullException(If(String.IsNullOrEmpty(jwtHeaderAndClaim), "jwtHeaderAndClaim", "sellerSecret"), "Argument cannot be null/empty")
            End If
        End Function

        ''' <summary>
        ''' Generate HMAC SHA-512 signature. Not used for Google In-App Payments (06/2011).
        ''' </summary>
        ''' <param name="jwtHeaderAndClaim">Concatenated Base64url Header Input and Payload Input, delimited by period (.)</param>
        ''' <param name="sellerSecret">Your Seller Secret</param>
        ''' <returns></returns>
        ''' <remarks></remarks>
        Private Function jwtHMAC512(ByVal jwtHeaderAndClaim As String, ByVal sellerSecret As String) As String
            If Not String.IsNullOrEmpty(jwtHeaderAndClaim) AndAlso Not String.IsNullOrEmpty(sellerSecret) Then
                Dim _utf8 As New UTF8Encoding(True, True)
                Dim _key As Byte() = _utf8.GetBytes(sellerSecret)
                Dim _btxt As Byte() = _utf8.GetBytes(jwtHeaderAndClaim)
                Dim _str As String = String.Empty
                Using hmac As New HMACSHA512(_key)
                    _str = System.Convert.ToBase64String(hmac.ComputeHash(_btxt)).Replace("=", String.Empty).Replace("+", "-").Replace("/", "_")
                    hmac.Clear()
                End Using
                Debug.WriteLine("From hash: " & _str)
                Return _str
            Else
                Throw New ArgumentNullException(If(String.IsNullOrEmpty(jwtHeaderAndClaim), "jwtHeaderAndClaim", "sellerSecret"), "Argument cannot be null/empty")
            End If
        End Function

#End Region

    End Module

End Namespace